Существует стартап, который продаёт продукты питания.
На первом этапе необходимо разобраться:
Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Провели A/A/B-тестирование.
На втором этапе необходимо исследовать результаты A/A/B-эксперимента и выяснить, какой шрифт лучше.
Мой основной инструмент — pandas. Я подключаю эту библиотеку. Также подключаю библиотеки, seaborn, re, numpy и matplotlib - они понадобятся для моих исследований.
Дополнительно отключу предупреждения (библиотека warnings).
import pandas as pd
import datetime as dt
import numpy as np
import matplotlib.pyplot as plt
import numpy as np
import math as mth
from scipy import stats as st
from plotly import graph_objects as go
import warnings as wg
wg.filterwarnings('ignore')
Осуществляю чтение файла данных о заведениях общественного питания Москвы logs_exp.csv из папки /datasets и сохраняю его в переменной data. Вывожу на экран первые пять строк таблицы.
try:
data = pd.read_csv('/Users/a4128/Documents/My_projects/08_AAB-test/logs_exp.csv') # локальный путь
except:
data = pd.read_csv('/datasets/logs_exp.csv') # серверный путь
data.head(5)
| EventName\tDeviceIDHash\tEventTimestamp\tExpId | |
|---|---|
| 0 | MainScreenAppear\t4575588528974610257\t1564029... |
| 1 | MainScreenAppear\t7416695313311560658\t1564053... |
| 2 | PaymentScreenSuccessful\t3518123091307005509\t... |
| 3 | CartScreenAppear\t3518123091307005509\t1564054... |
| 4 | PaymentScreenSuccessful\t6217807653094995999\t... |
Данные склеены в одну строку вместо того, чтобы разбиться по колонкам. Нужно указать верный разделитель \t, как видно из таблицы выше:
try:
data = pd.read_csv('/Users/a4128/Documents/My_projects/08_AAB-test/logs_exp.csv', sep='\t')
except:
data = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
display(data.head())
print()
data.info()
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
Итак, полученные данные отформатированы, отображены и предварительно пранализированы. Таблица содержит 4 столбца и 244126 строк.
Согласно документации к данным:
EventName — название события;DeviceIDHash — уникальный идентификатор пользователя;EventTimestamp — время события;ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.На этапе предварительного анализа выявлено:
EventName, DeviceIDHash, EventTimestamp, ExpId);EventTimestamp).Для начала восстановлю стиль названий для тех колонок, где обнаружены нарушения, а именно составное имя переменной написано без знака подчёркивания - слитно и заглавные буквы:
# переименование столбцов
data = data.rename(columns={'EventName': 'event_name', 'DeviceIDHash': 'user_id', 'EventTimestamp': 'event_time', 'ExpId': 'test_id'})
data.columns # проверка
Index(['event_name', 'user_id', 'event_time', 'test_id'], dtype='object')
Формат столбца event_time - это UNIX-время или POSIX-время (англ. Unix time) - способ кодирования времени, принятый в UNIX и других POSIX-совместимых операционных системах.
Моментом начала отсчёта считается полночь (по UTC) с 31 декабря 1969 года на 1 января 1970.
Время UNIX согласуется с UTC, поэтому необходимо перевести дату в человекопонятный формат в соответствии с часовым поясом для Москвы.
data['event_time'] = pd.to_datetime(data['event_time'], unit='s') + pd.Timedelta(hours=3)
data.head()
| event_name | user_id | event_time | test_id | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 2019-07-25 07:43:36 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 2019-07-25 14:11:42 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 2019-07-25 14:28:47 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 2019-07-25 14:28:47 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 2019-07-25 14:48:42 | 248 |
Разделю полученную дату на столбец дат и столбец времени, так будет целесообразнее для последующего анализа.
data['date'] = data['event_time'].dt.date
data['time'] = data['event_time'].dt.time
data.head()
| event_name | user_id | event_time | test_id | date | time | |
|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 2019-07-25 07:43:36 | 246 | 2019-07-25 | 07:43:36 |
| 1 | MainScreenAppear | 7416695313311560658 | 2019-07-25 14:11:42 | 246 | 2019-07-25 | 14:11:42 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 2019-07-25 14:28:47 | 248 | 2019-07-25 | 14:28:47 |
| 3 | CartScreenAppear | 3518123091307005509 | 2019-07-25 14:28:47 | 248 | 2019-07-25 | 14:28:47 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 2019-07-25 14:48:42 | 248 | 2019-07-25 | 14:48:42 |
data[['date', 'time']].dtypes
date object time object dtype: object
data['date'] = pd.to_datetime(data['date'], format ='%Y-%m-%d')
data['time'] = pd.to_datetime(data['time'], format ='%H:%M:%S')
data[['date', 'time']].dtypes
date datetime64[ns] time datetime64[ns] dtype: object
print('Обнаружено явных дубликатов:', data.duplicated().sum())
print()
data = data.drop_duplicates().reset_index(drop = True)
print('После обработки:', data.duplicated().sum())
Обнаружено явных дубликатов: 413 После обработки: 0
На этапе предобработки данных:
print('Уникальные события:', data['event_name'].unique())
Уникальные события: ['MainScreenAppear' 'PaymentScreenSuccessful' 'CartScreenAppear' 'OffersScreenAppear' 'Tutorial']
events_all = len(data)
print('Всего в логе событий:', events_all)
Всего в логе событий: 243713
# Проверка
events_unique = (data
.groupby(['user_id', 'test_id', 'event_time'])['event_name']
.count()
.sort_values(ascending=False)
)
print('Всего в логе уникальных событий:', len(events_unique))
Всего в логе уникальных событий: 220557
Расхождение в полученных значениях связано с тем, что в один и тот же момент времени один и тот же пользователь смог выполнить несколько событий одновременно. Буду считать, что такое возможно и приму общее количество логов за 243713.
users_all = data['user_id'].nunique() # всего количество уникальных пользователей
print('Всего пользователей в логе:', users_all)
Всего пользователей в логе: 7551
print('В среднем на одного пользователя приходится событий:', round(len(data) / data['user_id'].nunique()))
В среднем на одного пользователя приходится событий: 32
print(data.groupby('test_id')['user_id'].nunique())
(
data.groupby('test_id')['user_id']
.nunique()
.plot.pie(autopct='%1.2f%%')
)
plt.title('Распределение пользователей по группам тестирования (исходные данные)')
plt.ylabel('Номер группы', color='grey');
test_id 246 2489 247 2520 248 2542 Name: user_id, dtype: int64
print('Минимальная дата:', data['date'].min())
print('Максимальная дата:', data['date'].max())
Минимальная дата: 2019-07-25 00:00:00 Максимальная дата: 2019-08-08 00:00:00
Имеющиеся данные относятся к периоду времени с 25 июля по 8 августа 2019 года.
data['event_time'].hist(bins=300, color='green', figsize=(15, 6))
plt.title('Распределение количества событий', fontsize=15)
plt.xlabel('Дата', fontsize=15, color='grey')
plt.ylabel('Количество событий', fontsize=15, color='grey')
plt.xticks(rotation=20);
По полученной гистограмме можно увидеть, что с начала исследуемого периода и до конца июля данных по активности пользователей мало, однако с начала августа и до конца исследуемого периода наблюдается совешенно другой характер активности. Здесь несколько вариантов, или приложение начало свою работу с определенного момента в середине исследуемого периода, либо (что скорее всего) предоставленные данные неполные. По существующей информации технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого, поэтому это может «перекашивать данные».
Необходимо определить дату, с какого момента данные являются полными, и отсечь более старые данные. Визуально можно увидеть, что скачок произошел где-то в конце июля, начале августа.
data.groupby('date')['date'].count()
date 2019-07-25 9 2019-07-26 30 2019-07-27 55 2019-07-28 104 2019-07-29 181 2019-07-30 397 2019-07-31 1213 2019-08-01 35767 2019-08-02 35571 2019-08-03 33602 2019-08-04 32583 2019-08-05 36075 2019-08-06 36270 2019-08-07 31788 2019-08-08 68 Name: date, dtype: int64
По полученной выборке однозначно можно сделать вывод, что наиболее полные данные представлены с 1 по 7 августа 2019 года. Именно эти данные будут принимать участие в анализе. Неполные данные с обеих сторон будут исключены из датафрейма.
# исключаемые данные
data_deleted = data.loc[(data['date'] < '2019-08-01') | (data['date'] > '2019-08-07')]
# обновленные данные
data = data.loc[(data['date'] >= '2019-08-01') & (data['date'] <= '2019-08-07')]
Проверю, много ли событий и уникальных пользователей я потеряла, отбросив старые данные.
print('Количество потерянных событий:',
len(data_deleted),
'это',
round(len(data_deleted) / events_all * 100, 2),
'% от общего числа событий'
)
print()
print('Количество потерянных уникальных пользователей:',
users_all - data['user_id'].nunique(),
'это',
round((users_all - data['user_id'].nunique()) / users_all, 3),
'% от общего количества пользователей')
Количество потерянных событий: 2057 это 0.84 % от общего числа событий Количество потерянных уникальных пользователей: 13 это 0.002 % от общего количества пользователей
Проверю, все ли 3 экспериментальные группы представлены в отобранных данных после отсеивания старых данных.
print(data.groupby('test_id')['user_id'].nunique())
(
data.groupby('test_id')['user_id']
.nunique()
.plot.pie(autopct='%1.2f%%')
)
plt.title('Распределение пользователей по группам тестирования (исходные данные)')
plt.ylabel('Номер группы', color='grey');
test_id 246 2484 247 2517 248 2537 Name: user_id, dtype: int64
Полученные исходные данные соответствуют периоду времени с 25 июля по 8 августа 2019 года. Данная выборка содержит информацию по 243713 событиям, совершенным в мобильном приложении 7551 пользователем. По 3 группам тестирования пользователи распределены практически идеально в равных долях:
После проведения анализа выявлено, что не все предоставленные данные за данный период являются полными. По существующей информации технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого, поэтому это может «перекашивать данные». В связи с этим было принято решение отсечь временные отрезки, где данных не хватает, а именно период с 25 июля по 31 июля и дату 8 августа.
В итоге актульные данные, которые будут исследованы, имеют следующие характеристики:
При удалении старых данных из исходной выборки потеряно менее 1% от общего числа событий, а количество потерянных уникальных пользователей составило 0.002% от общего количества пользователей, что не может повлиять на результаты последующего анализа.
report = data['event_name'].value_counts().sort_values(ascending=False)
print(report)
report.plot(linewidth=2.0, grid=True, marker='o', figsize=(10,5))
plt.title('Распределение количества пользователей по шагам', fontsize=15)
plt.ylabel('Количество пользователей', fontsize=13, color='grey')
plt.xlabel('Событие', fontsize=13, color='grey')
plt.xticks(rotation=20)
plt.show();
MainScreenAppear 117850 OffersScreenAppear 46509 CartScreenAppear 42338 PaymentScreenSuccessful 33949 Tutorial 1010 Name: event_name, dtype: int64
В логе представлена информация по 5 событиям:
Из полученных данных и графика можно увидеть, что чаще у пользователей открыта главная страница, далее экран с предложением, потом страница с проведением оплаты, реже - экран у успешной оплатой. И очень редко пользователи открывают страницу с руководством.
Рассчитаю, сколько пользователей совершали каждое из этих событий. Посчитаю долю пользователей, которые хоть раз совершали то или иное событие. Построю воронку переходов на страницы мобильного приложения.
result = (data
.groupby('event_name')
.agg({'event_name': 'count', 'user_id': 'nunique'})
)
result['conv %'] = round(result['user_id'] / data['user_id'].nunique() * 100, 2)
result = result.sort_values(by='conv %', ascending=False)
result = result.rename(columns={'event_name': 'events', 'user_id': 'users'})
result
| events | users | conv % | |
|---|---|---|---|
| event_name | |||
| MainScreenAppear | 117850 | 7423 | 98.47 |
| OffersScreenAppear | 46509 | 4596 | 60.97 |
| CartScreenAppear | 42338 | 3736 | 49.56 |
| PaymentScreenSuccessful | 33949 | 3540 | 46.96 |
| Tutorial | 1010 | 843 | 11.18 |
Построение маркетинговой воронки.
# добавлю столбец со всеми пользователями, посетившими сайт
funnel = result.reset_index()
append_all_visitors = {'event_name': 'VisitSite', 'users': data['user_id'].nunique()}
funnel = funnel[['event_name', 'users']].append(append_all_visitors, ignore_index=True)
funnel = funnel.sort_values(by='users', ascending=False)
# построю воронку посещения страниц мобильного приложения
fig = go.Figure(go.Funnel(
y = funnel['event_name'],
x = funnel['users'],
textposition = "inside",
textinfo = "value+percent initial",
opacity = 0.65, marker = {"color": ["deepskyblue", "lightsalmon", "tan", "teal", "silver"],
"line": {"width": [4, 2, 2, 3, 1, 1], "color": ["wheat", "wheat", "blue", "wheat", "wheat"]}},
connector = {"line": {"color": "royalblue", "dash": "dot", "width": 3}})
)
fig.show();
Из полученных данных можно увидеть, что почти все пользователи переходят на главный экран (98.5%), на экран с предложениями заходят около 60%, на экран оплаты переходит половина от всех пользователей, а экран с успешной оплатой появляется у 47% пользователей, только 11% переходят на экран с руководством. Можно предположить, что правильная последовательность - это:
Главный экран -> экран предложения -> экран оплаты -> экран успешной оплаты
Скорее всего, руководство - это экран, не относящийся к данной воронке, это опциональное окно, на которое можно зайти, а можно и не заходить, его не нужно учитывать при расчёте конверсии.
Построю распределение по датам, определю шаги воронки следующим обрзоам:
step_1 - Главный экранstep_2 - Предложениеstep_3 - Оплатаstep_4 - Успешная оплатаРассчитаю конверсию от шага к шагу, чтобы определить, на каком шаге теряются пользователи, а также долю пользователей, прошедших все шаги до успешной оплаты.
funnel = (data
.pivot_table(index='date',
columns='event_name',
values='user_id',
aggfunc='count')
.reset_index()
)
funnel = funnel.rename(columns={'MainScreenAppear': 'step_1', 'OffersScreenAppear': 'step_2', 'CartScreenAppear': 'step_3', 'PaymentScreenSuccessful': 'step_4'})
funnel = funnel[['date', 'step_1', 'step_2', 'step_3', 'step_4']]
funnel['conv_1-2'] = round(funnel['step_2'] / funnel['step_1'] * 100, 2)
funnel['conv_2-3'] = round(funnel['step_3'] / funnel['step_2'] * 100, 2)
funnel['conv_3-4'] = round(funnel['step_4'] / funnel['step_3'] * 100, 2)
users_final = round(funnel['step_4'].sum() / funnel['step_1'].sum() * 100, 2)
funnel
| event_name | date | step_1 | step_2 | step_3 | step_4 | conv_1-2 | conv_2-3 | conv_3-4 |
|---|---|---|---|---|---|---|---|---|
| 0 | 2019-08-01 | 17992 | 6983 | 6011 | 4588 | 38.81 | 86.08 | 76.33 |
| 1 | 2019-08-02 | 16745 | 6855 | 6574 | 5201 | 40.94 | 95.90 | 79.11 |
| 2 | 2019-08-03 | 14958 | 6815 | 6468 | 5201 | 45.56 | 94.91 | 80.41 |
| 3 | 2019-08-04 | 14966 | 6485 | 6074 | 4920 | 43.33 | 93.66 | 81.00 |
| 4 | 2019-08-05 | 17845 | 6708 | 6319 | 5084 | 37.59 | 94.20 | 80.46 |
| 5 | 2019-08-06 | 19063 | 6587 | 5802 | 4689 | 34.55 | 88.08 | 80.82 |
| 6 | 2019-08-07 | 16281 | 6076 | 5090 | 4266 | 37.32 | 83.77 | 83.81 |
print('Средняя конверсия по дням на шагах 1-2:', funnel['conv_1-2'].mean().astype('int'), '%')
print('Средняя конверсия по дням на шагах 2-3:', funnel['conv_2-3'].mean().astype('int'), '%')
print('Средняя конверсия по дням на шагах 3-4:', funnel['conv_3-4'].mean().astype('int'), '%')
print()
print('Доля пользователей, прошедших все шаги до успешной оплаты:', users_final, '%')
Средняя конверсия по дням на шагах 1-2: 39 % Средняя конверсия по дням на шагах 2-3: 90 % Средняя конверсия по дням на шагах 3-4: 80 % Доля пользователей, прошедших все шаги до успешной оплаты: 28.81 %
Данные проанализированы и сделаны следующие выводы:
Почти все пользователи из выборки переходят на главный экран (98.5%), на экран с предложениями заходят около 60%, на экран оплаты переходит половина от всех пользователей, а экран с успешной оплатой появляется у 47% пользователей, только 11% переходят на экран с руководством.
Воронкой событий принята следующая последовательность:
Главный экран -> экран предложения -> экран оплаты -> экран успешной оплаты
В результате проведенных исследований выявлено, что большинство пользователей теряется на первом шаге перехода с главной страницы на страницу с предложением, средняя конверсия на этом шаге составляет составляет всего 39%, минимальная конверсия на этом шаге может достигать 34.5%. Однако те, кто смотрит предложение, в среднем с 90% вероятностью переходят на страницу оплаты - это очень хороший показатель конверсии, 2 августа он достиг почти 96%. На этапе оплаты теряется в среднем около 20% пользователей.
Доля пользователей, прошедших все шаги до успешной оплаты составляет около 30%.
Пользователи разбиты на 3 группы: 2 контрольные со старыми шрифтами (246 и 247) и одну экспериментальную — с новыми (248). Рлсчитаю количество пользователей в группах.
groups = data.groupby('test_id')['user_id'].nunique().reset_index()
print('Количество пользователей в сформированных группах:')
display(groups)
Количество пользователей в сформированных группах:
| test_id | user_id | |
|---|---|---|
| 0 | 246 | 2484 |
| 1 | 247 | 2517 |
| 2 | 248 | 2537 |
В процентном соотношении по 3 группам тестирования пользователи распределены равномерно, количество пользователей в группах различается не более, чем на 1%, что является критерием успешного проведения статистических тестов:
246 и 247 группы - это контрольные группы для А/А-эксперимента для проверки корректности всех механизмов и расчётов. До начала исследования необходимо проверить, нет ли пересечений пользователей между группами, т.к. если один и тот же пользователь находится в разных экспериментальных группах, может быть нарушена чистота эксперимента.
users_groups = (data
.groupby('user_id')['test_id']
.nunique()
.sort_values(ascending=False)
.head()
)
users_groups
user_id 6888746892508752 1 6207091767962101846 1 6217295124800833842 1 6216080220799726690 1 6215559225876063378 1 Name: test_id, dtype: int64
Не найдено ни одного пользователя, задействованного более, чем в 1 группе. это значит, что разделение пользователей на группы - корректно.
# рассчитаю количество пользователей в каждой группе, совершивших каждое событие
report = (data
.query('event_name != "Tutorial"')
.pivot_table(index='event_name',
columns='test_id',
values='user_id',
aggfunc='nunique')
.sort_values(by='event_name')
.reset_index()
)
# для того, чтобы не нарушался порядок, проиндексирую названия
replace_values = {'MainScreenAppear': '1_MainScreenAppear',
'OffersScreenAppear': '2_OffersScreenAppear',
'CartScreenAppear': '3_CartScreenAppear',
'PaymentScreenSuccessful': '4_PaymentScreenSuccessful'}
def multiple_replace(target, replace_values):
for i, j in replace_values.items():
# меняем все target на подставляемое
target = target.replace(i, j)
return target
report['event_name'] = multiple_replace(report['event_name'], replace_values)
# рассчитаю долю пользователей в каждой группе, совершивших каждое событие
report = report.sort_values(by='event_name')
report['246_%'] = round(report[246] / data.query('test_id == 246')['user_id'].nunique() * 100, 2)
report['247_%'] = round(report[247] / data.query('test_id == 247')['user_id'].nunique() * 100, 2)
report['248_%'] = round(report[248] / data.query('test_id == 248')['user_id'].nunique() * 100, 2)
report
| test_id | event_name | 246 | 247 | 248 | 246_% | 247_% | 248_% |
|---|---|---|---|---|---|---|---|
| 1 | 1_MainScreenAppear | 2450 | 2479 | 2494 | 98.63 | 98.49 | 98.31 |
| 2 | 2_OffersScreenAppear | 1542 | 1523 | 1531 | 62.08 | 60.51 | 60.35 |
| 0 | 3_CartScreenAppear | 1266 | 1239 | 1231 | 50.97 | 49.23 | 48.52 |
| 3 | 4_PaymentScreenSuccessful | 1200 | 1158 | 1182 | 48.31 | 46.01 | 46.59 |
(report[['event_name', '246_%', '247_%', '248_%']]
.plot(x='event_name',
grid=True,
linewidth = 2, marker='o',
figsize=(15,6))
)
plt.title('Распределение доли пользователей по шагам в группах', fontsize=15)
plt.ylabel('Доля пользователей', fontsize=13, color='grey')
plt.xlabel('Событие', fontsize=13, color='grey')
plt.show;
По графику можно заметить, что доля пользователей, совершивших каждое событие, в группах различется. Необходимо выяснить, статистически значимые ли эти отличия, или нет.
report = report.reset_index(drop=True)
groups = (data
.query('event_name != "Tutorial"')
.pivot_table(columns='test_id',
values='user_id',
aggfunc='nunique')
.reset_index(drop=True)
)
display(groups, report)
| test_id | 246 | 247 | 248 |
|---|---|---|---|
| 0 | 2483 | 2516 | 2535 |
| test_id | event_name | 246 | 247 | 248 | 246_% | 247_% | 248_% |
|---|---|---|---|---|---|---|---|
| 0 | 1_MainScreenAppear | 2450 | 2479 | 2494 | 98.63 | 98.49 | 98.31 |
| 1 | 2_OffersScreenAppear | 1542 | 1523 | 1531 | 62.08 | 60.51 | 60.35 |
| 2 | 3_CartScreenAppear | 1266 | 1239 | 1231 | 50.97 | 49.23 | 48.52 |
| 3 | 4_PaymentScreenSuccessful | 1200 | 1158 | 1182 | 48.31 | 46.01 | 46.59 |
Напишу функцию, определяющую, достаточна ли разница в пропорциях при указанных размерах выборок, чтобы говорить о статистически значимом различии в группах.
def calculation(alpha, step, group_1, group_2):
alpha = alpha # критический уровень статистической значимости
success = np.array([report.at[step, group_1], report.at[step, group_2]])
trial = np.array([groups.at[0, group_1], groups.at[0, group_2]])
p1 = success[0]/trial[0] # пропорция успехов в первой группе:
p2 = success[1]/trial[1] # пропорция успехов во второй группе:
# пропорция успехов в комбинированном датасете:
p_combined = (success[0] + success[1]) / (trial[0] + trial[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаю статистику в стандартных отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trial[0] + 1/trial[1]))
# задаю стандартное нормальное распределение (среднее 0, стандартное отклонение 1)
distr = st.norm(0, 1)
# рассчитываю p_value
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между группами есть значимая разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными')
В моём исследовании предстоит провести несколько сравнений на одних и тех же данных — это является множественным тестом. Его важная особенность в том, что с каждой новой проверкой гипотезы растёт вероятность ошибки первого рода, т.е. получения ложнопозитивного результата данного статистического теста.
Я буду проверять 16 гипотез о статистической значимости, а именно:
246 / 247 для 4 шагов (4 теста)246 / 248 (4 теста)247 / 248 (4 теста)(246+247) / 248 (4 теста)Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез, а именно, уточнить критический уровень статистической значимости со стандартных 5% я буду использовать поправку Бонферрони, т.е. разделю уровень значимости на число проверяемых гипотез.
alpha = 0.05 / 16
print('Выбранный критический уровень статистической значимости:', alpha)
Выбранный критический уровень статистической значимости: 0.003125
На первом этапе необходимо проверить, находят ли статистические критерии разницу между контрольными выборками.
Для этого рассчитаю число пользователей, совершивших каждое из событий в воронке для каждой из контрольных групп (246 и 247). Посчитаю долю пользователей, совершивших каждое событие, и проверю, будет ли отличие между группами статистически достоверным.
Буду проверять результаты z-теста для 4 шагов в 2-ух контрольных группах: 246 и 247. Параметры для написанной функции будут следующие:
alpha=0.003125group_1=246;group_2=247;step=0;step=1;step=2step=3.Сформулирую нулевую и альтернативные гипотезы, которые буду проверять:
steps = [0, 1, 2, 3]
for element in steps:
print('Проверка шага №', element + 1, 'в группах: 246 / 247')
calculation(0.003125, element, 246, 247)
print()
Проверка шага № 1 в группах: 246 / 247 p-значение: 0.6702082653142836 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 2 в группах: 246 / 247 p-значение: 0.2545545387352113 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 3 в группах: 246 / 247 p-значение: 0.21811883651016095 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 4 в группах: 246 / 247 p-значение: 0.10288527362638322 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными
По полученным результатам z-теста можно разбиение на контрольные группы А/А считать корректным. Две контрольные группы оказались равны, поэтому можно быть уверенным в точности проведенного тестирования.
Проведу Z-тест для группы с изменённым шрифтом (248). Для достоверности результатов я буду сравнивать экспериментальную группу 248 с каждой их контрольных групп (246 и 247). Параметры для функции расчета будут следующие:
alpha=0.003125group_1=246;group_2=248;step=0;step=1;step=2step=3.alpha=0.003125group_1=247;group_2=248;step=0;step=1;step=2step=3.Нулевая и альтернативная гипотезы, которые буду проверять:
for element in steps:
print('Проверка шага №', element + 1, 'в группах: 246 / 248')
calculation(0.003125, element, 246, 248)
print()
print()
for element in steps:
print('Проверка шага №', element + 1, 'в группах: 247 / 248')
calculation(0.003125, element, 247, 248)
print()
Проверка шага № 1 в группах: 246 / 248 p-значение: 0.396910049618151 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 2 в группах: 246 / 248 p-значение: 0.21442476639710506 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 3 в группах: 246 / 248 p-значение: 0.08564271892834707 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 4 в группах: 246 / 248 p-значение: 0.22753674585530037 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 1 в группах: 247 / 248 p-значение: 0.6723167704766229 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 2 в группах: 247 / 248 p-значение: 0.9200426006644042 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 3 в группах: 247 / 248 p-значение: 0.6264599792848009 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 4 в группах: 247 / 248 p-значение: 0.6680367850275775 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными
На данном этапе проверок выяснено, что статистически значимая разница между группами отсутствует по всем этапам воронки.
Нулевая и альтернативная гипотезы, которые буду проверять:
groups['246+247'] = groups[246] + groups[247]
report['246+247'] = report[246] + report[247]
for element in steps:
print('Проверка шага №', element + 1, 'в группах: 246+247 / 248')
calculation(0.003125, element, '246+247', 248)
print()
Проверка шага № 1 в группах: 246+247 / 248 p-значение: 0.4599468774918498 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 2 в группах: 246+247 / 248 p-значение: 0.4402711073657435 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 3 в группах: 246+247 / 248 p-значение: 0.20361356481451098 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными Проверка шага № 4 в группах: 246+247 / 248 p-значение: 0.6559128929243401 Не получилось отвергнуть нулевую гипотезу, нет оснований считать группы разными
На данном этапе проверок определено, что статистически значимая разница между контрольной объединённой группой и экспериментальной группой также отсутствует по всем этапам воронки.
Проведено исследование результатов A/A/B-эксперимента.
Проверено 16 гипотез на одних и тех же данных. Для снижения вероятности ложнопозитивного результата при множественном тестировании гипотез, с использованием поправки Бонферрони уточнен критический уровень статистической значимости со стандартных 5% до 0.31%.
По результатам z-теста определено, что контрольные группы равны, поэтому разбиение можно считать корректным.
Проведены тесты по определению статистической значимости между контрольными группами по-отдельности, а также объединенной контрольной группы с экспериментальной группой. Определено, что статистически значимая разница между группами отсутствует по всем этапам воронки, из чего можно сделать вывод, что изменение шрифтов никак не повлияло на активность пользователей в приложении.
Итак, данные о событиях пользователей мобильного приложения за период с 25 июля по 8 августа 2019 года получены, отформатированы, предварительно пранализированы.
На этапе предобработки данных:
На этапе подготовки данных к анализу выявлено, что не все предоставленные данные за период являются полными. Работа велась по скорректированным данным за период с 1 по 7 августа 2019 года, остальные данные были исключены из анализа.
Актульные данные, подлежащие исследованию, имеют следующие характеристики:
При удалении старых данных из исходной выборки потеряно менее 1% от общего числа событий, а количество потерянных уникальных пользователей составило 0.002% от общего количества пользователей, что не повлияло на результаты статистических тестов.
Проанализирована воронка событий. Главный экран -> экран предложения -> экран оплаты -> экран успешной оплаты.
Почти все пользователи из выборки переходят на главный экран (98.5%), на экран с предложениями заходят около 60%, на экран оплаты переходит половина от всех пользователей, а экран с успешной оплатой появляется у 47% пользователей.
В результате проведенных исследований выявлено, что большинство пользователей теряется на первом шаге перехода с главной страницы на страницу с предложением, средняя конверсия на этом шаге составляет составляет всего 39%, минимальная конверсия на этом шаге может снижаться до 34.5%. Однако те, кто смотрит предложение, в среднем с 90% вероятностью переходят на страницу оплаты - это очень хороший показатель конверсии, 2 августа он достиг почти 96%. На этапе оплаты теряется в среднем около 20% пользователей.
Доля пользователей, прошедших все шаги до успешной оплаты составляет около 30%.
Что касается изменения шрифтов в приложении, то можно с уверенностью сказать, что изменение шрифта никак не повлияет на пользовательскую активность, это проверено 3 статистическими тестами (+ дополнительный тест для контрольных групп для повышения точности проводимого тестирования), которые показали, что статистически значимая разница между группами с разными шрифтами отсутствует, даже с учётом поправки на критический уровень статистической значимости вследствии множественного тестирования.